import { runTasks, queueAsyncTask, queueTask, TaskAbortError, tasksSettled, queueRecurringTask, getRecurringTasks } from '@aurelia/runtime';
import { assert, createFixture } from '@aurelia/testing';

describe('2-runtime/queue.spec.ts', function () {
  beforeEach(async function () {
    assert.strictEqual(await tasksSettled(), false, `queue should be empty prior to each test`);
  });
  afterEach(async function () {
    assert.strictEqual(await tasksSettled(), false, `queue should be empty after each test`);
  });

  describe('queueTasks', function () {
    it('runs a task in the queue', async function () {
      let i = 0;
      queueTask(() => {
        i++;
      });
      assert.strictEqual(i, 0, 'Task should not have run yet');
      await tasksSettled();
      assert.strictEqual(i, 1, 'Task should have run once');
    });

    it('continuously runs until no more tasks queued', async function () {
      let i = 1;
      const callback = () => {
        if (i++ < 10) {
          queueTask(callback);
        }
      };
      queueTask(callback);

      try {
        await Promise.any([
          tasksSettled(),
          new Promise<void>((resolve) => setTimeout(resolve))
        ]);
        assert.strictEqual(i, 11, 'Task should have run 10 times');
      } catch (e) {
        assert.strictEqual(true, false, `Expected no error to be thrown. error: ${e}`);
      }
    });

    it('can be awaited with an ordinary tick (even if nested)', async function () {
      const stack: string[] = [];
      queueTask(() => {
        stack.push('Sync1');

        queueTask(() => {
          stack.push('Sync2');

          queueTask(() => {
            stack.push('Sync3');
          });
        });
      });

      await Promise.resolve();

      assert.deepStrictEqual(stack, [
        'Sync1',
        'Sync2',
        'Sync3',
      ], 'stack mismatch');
    });
  });

  describe('queueAsyncTask', function () {
    it('catches errors in the queue in an application', async function () {
      const { component, assertText } = createFixture(
        '<div>${text}</div>',
        class {
          i = 0;
          get text() {
            if (this.i === 1) {
              throw new Error('Test error');
            }
            return 'foo';
          }
        }
      );

      assertText('foo');
      component.i = 1;
      try {
        await tasksSettled();
        assert.strictEqual(true, false, 'Expected an error to be thrown');
      } catch(e) {
        // Expected error to be thrown
        assert.strictEqual(e.message, 'Test error');
      }
    });

    it('doesnt throw error when task is canceled before it runs', async function () {
      const task = queueAsyncTask(() => {
        throw new Error('This should not run');
      });
      task.cancel();
      await tasksSettled();
      assert.strictEqual(task.status, 'canceled', 'Task should be canceled');
    });

    it('settleds error generated by task', async function () {
      void queueAsyncTask(() => {
        throw new Error('Task error');
      });
      try {
        await tasksSettled();
        assert.strictEqual(true, false, 'Expected an error to be thrown');
      } catch (e) {
        assert.strictEqual(e.message, 'Task error', 'Expected task error to be thrown');
      }
    });
  });

  describe('mixed scenarios', function () {
    it('tasksSettled correctly waits for tasks queued by async resolution of prior task', async function () {
      const stack: string[] = [];
      queueTask(() => {
        stack.push('Sync1_Start');

        void queueAsyncTask(() => {
          stack.push('Async2_SyncPart_Start');

          return new Promise<void>(resolve => {
            setTimeout(() => {
              stack.push('Async2_AsyncPart_Resolving');

              queueTask(() => {
                stack.push('Sync3_From_Async2');
              });

              stack.push('Async2_AsyncPart_End');
              resolve();
            }, 0);
          });
        });

        stack.push('Sync1_End');
      });

      await tasksSettled();

      assert.deepStrictEqual(stack, [
        'Sync1_Start',
        'Sync1_End',
        'Async2_SyncPart_Start',
        'Async2_AsyncPart_Resolving',
        'Async2_AsyncPart_End',
        'Sync3_From_Async2'
      ], 'stack mismatch');
    });
  });

  it('tasksSettled rejects when one async task fails, while others complete', async function () {
    const stack: string[] = [];
    let settledError: any = null;

    const rejectingAsyncTask = queueAsyncTask(() => {
      stack.push('RejectAsync_SyncPart_Start');
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          stack.push('RejectAsync_AsyncPart_Rejecting');

          reject(new Error('AsyncFailure'));
        }, 0);
      });
    });

    queueTask(() => {
      stack.push('SyncTask_Alongside');
    });

    const succeedingAsyncTask = queueAsyncTask(() => {
      stack.push('SucceedAsync_SyncPart_Start');

      return new Promise<string>(resolve => {
        setTimeout(() => {
          stack.push('SucceedAsync_AsyncPart_Resolving');

          resolve('SuccessData');
        }, 10);
      });
    });

    try {
      await tasksSettled();
    } catch (e) {
      settledError = e;
    }

    assert.strictEqual(settledError?.message, 'AsyncFailure', 'tasksSettled rejection message incorrect');

    assert.deepStrictEqual(stack, [
      'RejectAsync_SyncPart_Start',
      'SyncTask_Alongside',
      'SucceedAsync_SyncPart_Start',
      'RejectAsync_AsyncPart_Rejecting',
      'SucceedAsync_AsyncPart_Resolving'
    ], 'stack mismatch');

    assert.strictEqual(rejectingAsyncTask.status, 'canceled', 'rejecting task status should be canceled');
    try {
      await rejectingAsyncTask;
      assert.fail('Rejecting task result should have rejected');
    } catch (e: any) {
      assert.strictEqual(e.message, 'AsyncFailure', 'rejecting task result error message incorrect');
    }

    assert.strictEqual(succeedingAsyncTask.status, 'completed', 'succeeding task status should be completed');
    assert.strictEqual(await succeedingAsyncTask, 'SuccessData', 'succeeding task result data incorrect');
  });

  it('correctly handles pre-emptive cancellation of an async task', async function () {
    const stack: string[] = [];

    queueTask(() => {
      stack.push('Sync1');
    });

    const taskToCancel = queueAsyncTask(() => {
      stack.push('Async2_ToCancel');
    });

    queueTask(() => {
      stack.push('Sync3');
    });

    const wasCancelled = taskToCancel.cancel();

    await tasksSettled();

    assert.strictEqual(wasCancelled, true, 'task.cancel() should return true for a pending task');
    assert.strictEqual(taskToCancel.status, 'canceled', 'cancelled task status incorrect');

    assert.deepStrictEqual(stack, [
      'Sync1',
      'Sync3',
    ], 'stack mismatch');

    try {
      await taskToCancel;
      assert.fail('Cancelled task result should have rejected');
    } catch (e: any) {
      assert.instanceOf(e, TaskAbortError, 'cancelled task should reject with TaskAbortError');
    }
  });

  it('emits an unhandledRejection when a task throws a non-abort error', async function () {
    const unhandled: unknown[] = [];
    let handler: (reason: unknown) => void;
    let dispose: () => void;

    if (typeof process !== 'undefined' && typeof process.on === 'function') {
      handler = (err: Error) => unhandled.push(err);
      process.on('unhandledRejection', handler);
      dispose = () => process.off('unhandledRejection', handler);
    } else {
      handler = (ev: PromiseRejectionEvent) => unhandled.push(ev.reason);
      addEventListener('unhandledrejection', handler);
      dispose = () => removeEventListener('unhandledrejection', handler);
    }

    let taskErr: Error;
    const task = queueAsyncTask(() => {
      taskErr = new Error('Task error');
      throw taskErr;
    });

    try {
      // Wait a macrotask turn (without using tasksSettled) to mimic application code without a rejection handler.
      await new Promise<void>(resolve => setTimeout(resolve));

      // Verify that the TaskAbortError catch handler does not suppress other errors
      assert.strictEqual(unhandled[0], taskErr, 'Throwing an error in a task without a rejection handler should emit an unhandledRejection event');
    } finally {
      dispose();
    }

    try {
      // Awaiting task should also yield the same error
      await task;
      assert.fail('Task should have rejected');
    } catch (e: unknown) {
      assert.strictEqual(e, taskErr, 'Task should return the error that was thrown inside the task');
    }
  });

  it('emits an unhandledRejection when a task throws a non-abort error and tasksSettled is used', async function () {
    const unhandled: unknown[] = [];
    let handler: (reason: unknown) => void;
    let dispose: () => void;

    if (typeof process !== 'undefined' && typeof process.on === 'function') {
      handler = (err: Error) => unhandled.push(err);
      process.on('unhandledRejection', handler);
      dispose = () => process.off('unhandledRejection', handler);
    } else {
      handler = (ev: PromiseRejectionEvent) => unhandled.push(ev.reason);
      addEventListener('unhandledrejection', handler);
      dispose = () => removeEventListener('unhandledrejection', handler);
    }

    let taskErr: Error;
    const task = queueAsyncTask(() => {
      taskErr = new Error('Task error');
      throw taskErr;
    });

    try {
      try {
        await tasksSettled();
        assert.fail('tasksSettled should have rejected');
      } catch (e) {
        assert.strictEqual(e, taskErr);
      }

      await new Promise<void>(resolve => setTimeout(resolve));

      // Verify that the TaskAbortError catch handler and tasksSettled does not suppress other errors
      assert.strictEqual(unhandled[0], taskErr, 'Throwing an error in a task without a rejection handler should emit an unhandledRejection event');
    } finally {
      dispose();
    }

    try {
      // Awaiting task should also yield the same error
      await task;
      assert.fail('Task should have rejected');
    } catch (e: unknown) {
      assert.strictEqual(e, taskErr, 'Task should return the error that was thrown inside the task');
    }
  });

  it('does not emit an unhandledRejection when a task throws a non-abort error that is handled by awaiting task', async function () {
    const unhandled: unknown[] = [];
    let handler: (reason: unknown) => void;
    let dispose: () => void;

    if (typeof process !== 'undefined' && typeof process.on === 'function') {
      handler = (err: Error) => unhandled.push(err);
      process.on('unhandledRejection', handler);
      dispose = () => process.off('unhandledRejection', handler);
    } else {
      handler = (ev: PromiseRejectionEvent) => unhandled.push(ev.reason);
      addEventListener('unhandledrejection', handler);
      dispose = () => removeEventListener('unhandledrejection', handler);
    }

    let taskErr: Error;
    const task = queueAsyncTask(() => {
      taskErr = new Error('Task error');
      throw taskErr;
    });

    try {
      try {
        await tasksSettled();
        assert.fail('tasksSettled should have rejected');
      } catch (e) {
        assert.strictEqual(e, taskErr);
      }

      try {
        await task;
        assert.fail('Task should have rejected');
      } catch (e: unknown) {
        assert.strictEqual(e, taskErr, 'Task should return the error that was thrown inside the task');
      }

      // Wait a macrotask turn (without using tasksSettled) to mimic application code with a rejection handler.
      await new Promise<void>(resolve => setTimeout(resolve));

      assert.deepStrictEqual(unhandled, [], 'Throwing an error in a task with a rejection handler should not emit an unhandledRejection event');
    } finally {
      dispose();
    }
  });

  it('does not emit an unhandledRejection when a task is cancelled', async function () {
    const unhandled: unknown[] = [];
    const handler = (reason: unknown) => unhandled.push(reason);
    let dispose: () => void;

    if (typeof process !== 'undefined' && typeof process.on === 'function') {
      process.on('unhandledRejection', handler);
      dispose = () => process.off('unhandledRejection', handler);
    } else {
      addEventListener('unhandledrejection', handler);
      dispose = () => removeEventListener('unhandledrejection', handler);
    }

    const task = queueAsyncTask(() => {/* never runs */});
    const wasCancelled = task.cancel();

    try {
      assert.strictEqual(wasCancelled, true, 'task.cancel() should return true for a pending task');

      await tasksSettled();

      // Wait one more macrotask turn so the check-for-unhandled-rejection job can fire if it would have.
      await new Promise<void>(resolve => setTimeout(resolve));

      assert.deepStrictEqual(unhandled, [], 'Cancelling a task should not emit an unhandledRejection event');
    } finally {
      dispose();
    }

    try {
      await task;
      assert.fail('Cancelled task should have rejected');
    } catch (e: unknown) {
      assert.instanceOf(e, TaskAbortError);
    }
  });

  it('does not emit an unhandledRejection when a delayed task is cancelled before it starts', async function () {
    const unhandled: unknown[] = [];
    const handler = (reason: unknown) => unhandled.push(reason);
    let dispose: () => void;

    if (typeof process !== 'undefined') {
      process.on('unhandledRejection', handler);
      dispose = () => process.off('unhandledRejection', handler);
    } else {
      addEventListener('unhandledrejection', handler);
      dispose = () => removeEventListener('unhandledrejection', handler);
    }

    const task = queueAsyncTask(() => {/* never runs */}, { delay: 10 });
    await new Promise<void>(resolve => setTimeout(resolve, 0));
    const wasCancelled = task.cancel();

    try {
      assert.strictEqual(wasCancelled, true, 'task.cancel() should return true for a delayed task which is cancelled before it starts');

      await tasksSettled();

      // Wait one more macrotask turn so the check-for-unhandled-rejection job can fire if it would have.
      await new Promise<void>(resolve => setTimeout(resolve));

      assert.deepStrictEqual(unhandled, [], 'Timer-based cancellation should not emit unhandledRejection');
    } finally {
      dispose();
    }

    try {
      await task;
      assert.fail('Cancelled task should have rejected');
    } catch (e: unknown) {
      assert.instanceOf(e, TaskAbortError);
    }
  });

  it('tasksSettled handles deeply nested mixed task queuing', async function () {
    const stack: string[] = [];

    queueTask(() => { // Level 1 Sync
      stack.push('S1_Start');

      void queueAsyncTask(() => { // Level 2 Async
        stack.push('A2_SyncPart');

        return new Promise<void>(resolve_A2 => {
          setTimeout(() => {
            stack.push('A2_AsyncPart_Start');

            queueTask(() => { // Level 3 Sync (from A2's async)
              stack.push('S3_Start');

              void queueAsyncTask(() => { // Level 4 Async (from S3)
                stack.push('A4_SyncPart');

                return new Promise<void>(resolve_A4 => {
                  setTimeout(() => {
                    stack.push('A4_AsyncPart');

                    resolve_A4();
                  }, 0);
                });
              });

              stack.push('S3_End');
            });

            stack.push('A2_AsyncPart_End');

            resolve_A2();
          }, 0);
        });
      });

      stack.push('S1_End');
    });

    await tasksSettled();

    assert.deepStrictEqual(stack, [
      'S1_Start',
      'S1_End',
      'A2_SyncPart',
      'A2_AsyncPart_Start',
      'A2_AsyncPart_End',
      'S3_Start',
      'S3_End',
      'A4_SyncPart',
      'A4_AsyncPart'
    ], 'stack mismatch');
  });

  it('correctly handles a synchronous error within a queueAsyncTask', async function () {
    const stack: string[] = [];

    const faultyAsyncTask = queueAsyncTask(() => {
      stack.push('FaultyAsync_SyncPart_Start');

      throw new Error('ErrorIn_FaultyAsync_SyncPart');
    });

    queueTask(() => {
      stack.push('SyncTask_AfterFaulty');
    });

    const healthyAsyncTask = queueAsyncTask(async () =>{
      stack.push('HealthyAsync_SyncPart_Start');

      await Promise.resolve();

      stack.push('HealthyAsync_AsyncPart_End');

      return 'HealthySuccess';
    });

    let settledError: Error | null = null;

    try {
      await tasksSettled();
    } catch (e) {
      settledError = e;
    }

    assert.strictEqual(settledError?.message, 'ErrorIn_FaultyAsync_SyncPart');

    assert.deepStrictEqual(stack, [
      'FaultyAsync_SyncPart_Start',
      'SyncTask_AfterFaulty',
      'HealthyAsync_SyncPart_Start',
      'HealthyAsync_AsyncPart_End',
    ], 'stack mismatch');

    assert.strictEqual(faultyAsyncTask.status, 'canceled');

    await faultyAsyncTask.catch(e => {
      assert.strictEqual(e.message, 'ErrorIn_FaultyAsync_SyncPart');
    });

    assert.strictEqual(healthyAsyncTask.status, 'completed');
    assert.strictEqual(await healthyAsyncTask, 'HealthySuccess');
  });

  it('tasksSettled rejects with AggregateError for multiple queueTask errors', async function () {
    const stack: string[] = [];
    const error1 = new Error('SyncError1');
    const error2 = new Error('SyncError2');

    queueTask(() => {
      stack.push('Sync1_Throws');
      throw error1;
    });

    queueTask(() => {
      stack.push('Sync2_Runs');
    });

    queueTask(() => {
      stack.push('Sync3_Throws');
      throw error2;
    });

    let settledError: any = null;
    try {
      await tasksSettled();
    } catch (e) {
      settledError = e;
    }

    assert.instanceOf(settledError, AggregateError, 'settledError should be an AggregateError');
    assert.strictEqual(settledError.errors.length, 2, 'AggregateError should contain 2 errors');
    assert.strictEqual(settledError.errors[0], error1, 'error1');
    assert.strictEqual(settledError.errors[1], error2, 'error2');

    assert.deepStrictEqual(stack, [
      'Sync1_Throws',
      'Sync2_Runs',
      'Sync3_Throws'
    ], 'stack mismatch');
  });

  it('tasksSettled rejects with AggregateError for mixed sync and async rejections', async function () {
    const stack: string[] = [];
    const syncError = new Error('Mixed_SyncError');
    const asyncError = new Error('Mixed_AsyncRejection');
    let settledError: any = null;

    queueTask(() => {
      stack.push('Sync1_Throws');
      throw syncError;
    });

    const rejectingAsyncTask = queueAsyncTask(() => {
      stack.push('Async2_SyncPart');
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          stack.push('Async2_AsyncPart_Rejecting');
          reject(asyncError);
        }, 0);
      });
    });

    queueTask(() => {
      stack.push('Sync3_Runs');
    });

    try {
      await tasksSettled();
    } catch (e) {
      settledError = e;
    }

    assert.instanceOf(settledError, AggregateError, 'settledError should be an AggregateError');
    assert.strictEqual(settledError.errors.length, 2, 'AggregateError should contain 2 errors');
    assert.strictEqual(settledError.errors[0], syncError, 'SyncError');
    assert.strictEqual(settledError.errors[1], asyncError, 'AsyncError');

    assert.deepStrictEqual(stack, [
      'Sync1_Throws',
      'Async2_SyncPart',
      'Sync3_Runs',
      'Async2_AsyncPart_Rejecting'
    ], 'stack mismatch');

    assert.strictEqual(rejectingAsyncTask.status, 'canceled', 'RejectingAsyncTask status');
    await rejectingAsyncTask.catch(e => {
      assert.strictEqual(e, asyncError, 'RejectingAsyncTask result');
    });
  });

  it('tasksSettled reflects errors even if a manual flush() also processes them', async function () {
    const stack: string[] = [];
    const error1 = new Error('ManualFlush_Error1');
    let flushError: any = null;
    let settledError: any = null;

    queueTask(() => {
      stack.push('Sync1_ManualFlush_Throws');
      throw error1;
    });

    queueTask(() => {
      stack.push('Sync2_ManualFlush_Runs');
    });

    // Call tasksSettled before manual flush to ensure we are awaiting its promise.
    const settlePromise = tasksSettled().catch(e => { settledError = e; });

    try {
      runTasks();
    } catch (e) {
      flushError = e;
    }

    await settlePromise;

    assert.strictEqual(settledError, error1, 'settledError should be the error from the task');
    assert.strictEqual(flushError, error1, 'manual flush should throw the task error directly if only one');

    assert.deepStrictEqual(stack, [
      'Sync1_ManualFlush_Throws',
      'Sync2_ManualFlush_Runs'
    ], 'stack mismatch');
  });

  it('tasksSettled catches tasks that are queued very late in a turn', async function () {
    const stack: string[] = [];

    let taskQueuedFromMicrotaskHasRun = false;

    void Promise.resolve().then(() => {
      stack.push('Microtask_QueueingNewTask');

      queueTask(() => {
        stack.push('Task_FromMicrotask');

        taskQueuedFromMicrotaskHasRun = true;
      });
    });

    await tasksSettled();

    assert.strictEqual(taskQueuedFromMicrotaskHasRun, true, 'Task queued from microtask should have run');
    assert.deepStrictEqual(stack, [
      'Microtask_QueueingNewTask',
      'Task_FromMicrotask',
    ], 'stack mismatch');
  });

  it('tasksSettled resolves to false if no work was scheduled after prior resolution', async function () {
    const stack: string[] = [];

    // Phase 1
    queueTask(() => {
      stack.push('A');
    });

    assert.strictEqual(await tasksSettled(), true, `settlePromise should resolve to true if work was performed`);

    assert.deepStrictEqual(stack, [
      'A',
    ], 'Phase 1 stack mismatch');

    assert.strictEqual(await tasksSettled(), false, `settlePromise should resolve to false if no work was performed`);

    stack.length = 0;

    // Phase 2
    queueTask(() => {
      stack.push('B');
    });

    const asyncTaskC = queueAsyncTask(async () => {
      stack.push('C_sync');

      await Promise.resolve();

      stack.push('C_async');
    });

    assert.strictEqual(await tasksSettled(), true, `settlePromise should resolve to true if work was performed in phase 2`);

    assert.deepStrictEqual(stack, [
      'B',
      'C_sync',
      'C_async',
    ], 'Phase 2 stack mismatch');

    assert.strictEqual(await tasksSettled(), false, `settlePromise should resolve to false if no work was performed in phase 2`);
    assert.strictEqual(asyncTaskC.status, 'completed');
  });

  describe('delay parameter', function () {
    it('waits for the specified delay before executing the task', async function () {
      const stack: string[] = [];
      const delay = 20;
      const startTime = performance.now();

      stack.push('start');

      const delayedTask = queueAsyncTask(() => {
        stack.push('delayed_task');
        const endTime = performance.now();
        assert.strictEqual(delayedTask.status, 'running', `Delayed task should have status 'running'`);
        // Math.ceil is for CI timing drift
        assert.greaterThanOrEqualTo(Math.ceil(endTime - startTime), delay, 'Task ran before the delay completed');
      }, { delay });

      assert.strictEqual(delayedTask.status, 'pending', `Delayed task should have status 'pending'`);

      await tasksSettled();

      stack.push('end');

      assert.deepStrictEqual(stack, [
        'start',
        'delayed_task',
        'end'
      ], 'stack mismatch');

      assert.strictEqual(delayedTask.status, 'completed', `Delayed task should have status 'completed'`);
    });

    it('executes non-delayed tasks before delayed tasks', async function () {
      const stack: string[] = [];

      void queueAsyncTask(() => {
        stack.push('A_delayed_1ms');
      }, { delay: 1 });

      void queueAsyncTask(() => {
        stack.push('B_async');
      });

      queueTask(() => {
        stack.push('C_sync');
      });

      await tasksSettled();

      assert.deepStrictEqual(stack, [
        'B_async',
        'C_sync',
        'A_delayed_1ms'
      ], 'stack mismatch');
    });

    it('can cancel a delayed task before it starts executing', async function () {
      const stack: string[] = [];

      const taskToCancel = queueAsyncTask(() => {
        stack.push('should_not_run');
      }, { delay: 20 });

      const taskToRun = queueAsyncTask(() => {
        stack.push('A_delayed_1ms');
      }, { delay: 1 });

      await new Promise(resolve => setTimeout(resolve, 1));

      assert.strictEqual(taskToRun.status, 'completed', `1ms delayed task should have status 'completed'`);

      const wasCancelled = taskToCancel.cancel();

      await tasksSettled();

      assert.strictEqual(wasCancelled, true, 'task.cancel() should return true for a delayed task');
      assert.strictEqual(taskToCancel.status, 'canceled', `20ms delayed task should have status 'canceled'`);

      try {
        await taskToCancel;
        assert.fail('Canceled task result should have rejected');
      } catch (e: any) {
        assert.instanceOf(e, TaskAbortError, 'Canceled task should reject with TaskAbortError');
      }

      assert.deepStrictEqual(stack, [
        'A_delayed_1ms'
      ], 'stack mismatch');
    });

    it('treats delay: 0 and delay: null as a non-delayed task', async function () {
      const stack: string[] = [];

      const taskA = queueAsyncTask(() => {
        stack.push('A_zero_delay');
      }, { delay: 0 });

      const taskB = queueAsyncTask(() => {
        stack.push('B_null_delay');
      }, { delay: null });

      queueTask(() => {
        stack.push('C_sync');
      });

      await tasksSettled();

      assert.deepStrictEqual(stack, [
        'A_zero_delay',
        'B_null_delay',
        'C_sync'
      ], 'stack mismatch');

      assert.strictEqual(taskA.status, 'completed', `Task with delay: 0 should have status 'completed'`);
      assert.strictEqual(taskB.status, 'completed', `Task with delay: null should have status 'completed'`);
    });

  });

  describe('queueRecurringTask', function () {
    describe('awaiting next() and cancellation', function () {
      it('works with interval: 0', async function () {
        let counter = 0;

        const task = queueRecurringTask(() => {
          counter++;
        }, { interval: 0 });

        await task.next();

        assert.strictEqual(counter, 1, 'counter mismatch');

        await task.next();

        assert.strictEqual(counter, 2, 'counter mismatch');

        task.cancel();

        await task.next();

        assert.strictEqual(counter, 2, 'counter mismatch');
      });

      it('works with interval: 10', async function () {
        let counter = 0;

        const task = queueRecurringTask(() => {
          counter++;
        }, { interval: 10 });

        await task.next();

        assert.strictEqual(counter, 1, 'counter mismatch');

        await task.next();

        assert.strictEqual(counter, 2, 'counter mismatch');

        task.cancel();

        await task.next();

        assert.strictEqual(counter, 2, 'counter mismatch');
      });
    });

    it('await tasksSettled() does not wait for recurring tasks', async function () {
        let counter = 0;

        const task = queueRecurringTask(() => {
          counter++;
        }, { interval: 0 });

        await tasksSettled();

        assert.strictEqual(counter, 0, 'counter mismatch');

        task.cancel();
    });

    describe('getRecurringTasks()', function () {
      it('includes a newly created task in the list', function () {
        const task = queueRecurringTask(() => { /* noop */ }, { interval: 100 });
        const tasks = getRecurringTasks();

        assert.includes(tasks, task, 'The new task should be in the active list');

        task.cancel();
      });

      it('removes a canceled task from the list', function () {
        const task = queueRecurringTask(() => { /* noop */ }, { interval: 100 });

        assert.includes(getRecurringTasks(), task, 'Task should be in the list initially');

        task.cancel();

        assert.notIncludes(getRecurringTasks(), task, 'The canceled task should be removed from the active list');
      });
    });

    describe('cancellation behavior', function () {
      it('does not run the callback if canceled immediately', async function () {
        let counter = 0;
        const task = queueRecurringTask(() => {
          counter++;
        }, { interval: 0 });

        task.cancel();

        await new Promise(r => setTimeout(r, 10));
        await tasksSettled();

        assert.strictEqual(counter, 0, 'counter should remain 0 for an immediately-canceled task');
      });

      it('resolves multiple pending next() promises on the same tick', async function () {
        let counter = 0;
        const task = queueRecurringTask(() => {
          counter++;
        }, { interval: 10 });

        const p1 = task.next();
        const p2 = task.next();

        await Promise.all([p1, p2]);

        assert.strictEqual(counter, 1, 'Both next() promises should resolve after one tick');

        task.cancel();
      });
    });
  });
});
